[PERF] MXFP4 (a4w4) MoE backend for gfx950 - #3470
Conversation
Add module_moe_mxfp4_aux for the MXFP4 (a4w4) MoE backend: fused token sort+quant, 3-stage sort, scale sort/shuffle, and weighted bf16 scatter-reduce (gfx950, Kimi-K2.5 / DSR shapes).
Add module_moe_mxfp4_gemm: the two MXFP4 (a4w4) MoE GEMMs (gemm1 fused SwiGLU, gemm2 down-proj) with codegen'd kernel-name dispatch (gen_instances.py), MFMA f4f4 microkernels, epilogs, non-temporal B-load dispatch, and XCD-swizzle interface (gfx950, Kimi-K2.5 / DSR).
Route mxfp4_moe_* kernel names to the _mxfp4_moe_run pipeline (sort/quant -> gemm1 -> gemm2 -> scatter_reduce) via a MOEMetadata pipeline hook, with shuffle_kind-tagged CSV lookup so the backend ships its own tuned rows alongside the default set. Add Kimi-K2.5 tuned CSV.
Add the optional mxfp4-intermediate path: gemm2 stages flat_out as packed fp4 + e8m0 (mxfp4-out epilog) and scatter_reduce_q reads it back as mxfp4, cutting the reduce's HBM traffic ~3.8x. Gated by a _MXFP4OUT tag in the tuned CSV (large-M buckets only, where the reduce win beats the gemm2 epilog overhead). bf16-intermediate stays default.
🏷️ CI GuideRuns automatically on every PR:
Extended tests (opt-in via labels):
|
|
Performance bench script: https://gist.github.com/ftyghome/088870c544693da57258d22968573620 |
|
@benenzhu it is launch/latency-bound' in your bench script you use s = time.perf_counter() and then time.perf_counter() - s. It counts all cpu overhead. The real run in e2e uses cudagraph that will not count. |
Hi Felix, Thanks for the review!
|
Hello @ftyghome, Could you please update the perf compare again? Collect the flydsl date with fused_moe interface, so that flydsl will be called through the moe tuner too. |
Hi updated: @lalala-sh
|
| } | ||
| if (NE == 257) { | ||
| if (MB == 32) { LAUNCH(257, 9, 32, 7168); return; } | ||
| } |
There was a problem hiding this comment.
why so many constraints here? Could you make it more general ? I think the kernels don't have these limitation?
There was a problem hiding this comment.
It's the deepseek & kimi shapes. only tested on this.
| } | ||
| if (NE == 257) { | ||
| if (MB == 16) { LAUNCH_IQ_ZI(257, 9, 16, 7168); return; } | ||
| } |
| if (NE == 257) { | ||
| if (MB == 16) { LAUNCH_IQ(257, 9, 16, 7168); return; } | ||
| } | ||
| } |
| } | ||
| if (NE == 257) { | ||
| if (MB == 32) { LAUNCH(257, 9, 32, 7168); return; } | ||
| if (MB == 128) { LAUNCH(257, 9, 128, 7168); return; } |
There was a problem hiding this comment.
Also make quant a general usable kernel?
There was a problem hiding this comment.
The general case is already covered by dynamic_mxfp4_quant in aiter, which work on arbitrary shapes. This mxfp4_moe_quant is a fused, shape-specialized variant (compile-time D_HIDDEN for vectorization, plus the optional fused zero-init) that only makes sense inside this MoE pipeline.
If you need it for more model shapes, the cheap path is to add template dispatch cases (keeps the compile-time perf) rather than making it fully runtime-general. Happy to do that as a follow-up if there's a concrete shape you need.
| __launch_bounds__(256, | ||
| is_nonatomic_v<kEpilog> ? 1 : | ||
| ((BM == 16) ? 4 : 2)) | ||
| kernel( |
There was a problem hiding this comment.
ROCm/FlyDSL#660 for gemm2, we try to impl a flydsl pipeline that's exactly the same as this one. Would you mind transfer the same style and infra for better long term maintaining?
There was a problem hiding this comment.
Sure. We also have a FlyDSL version of gemm1, and most of the performance already matches our HIP implementation in our tests (except M=4 case). We will upload it soon.
It would be great to sync with the ongoing gemm1/gemm2 FlyDSL work. We would be happy to work together on this and align the performance with our HIP implementation while moving toward FlyDSL.
There was a problem hiding this comment.
Yes. Thanks a lot. HIP backend is also great. But currently there are ck in hip, flydsl, which we are trying to unify. Current PR add another new one, we hope to maintain in one infra for all optimization if possible.
|
|
||
| if (D_HIDDEN == 7168 && D_INTER == 512) { | ||
| if (NE == 385) { LAUNCH_G2_MXFP4(385, 512, 7168); return; } | ||
| if (NE == 257) { LAUNCH_G2_MXFP4(257, 512, 7168); return; } |
There was a problem hiding this comment.
hard code shapes. also couldn't be extended.
| sorted_weights.data_ptr<float>(), \ | ||
| reinterpret_cast<__hip_bfloat16*>(out.data_ptr())) | ||
|
|
||
| if (D_HIDDEN == 7168 && TOPK == 9) { |
| const bool nt_hints = (MB >= 128); | ||
|
|
||
| #define LAUNCH_Q(D_HIDDEN_, TOPK_, NT_) \ | ||
| aiter::mxfp4_moe::moe_scatter_reduce::launch_mxfp4< \ |
There was a problem hiding this comment.
All input is quanted to fp4. It's ok for gsm8k but not in our many other real tests. could you also add flags to enable ?
There was a problem hiding this comment.
Made it opt-in: set AITER_MXFP4_INTERMEDIATE=1 to enable the fp4 path, otherwise it falls back to the accurate bf16 reduce by default. So your other evals are safe unless you turn it on. a3c9875
| const buffer_rsrc_t B_q_rsrc = | ||
| make_buffer_rsrc(B_q, | ||
| (uint32_t)((long long)NUM_EXPERTS * N_OUT * K_HALF * sizeof(__hip_fp4x2_storage_t))); | ||
| constexpr int kAS_bound_div = kAtomic ? BM_GRID : 32; |
There was a problem hiding this comment.
The A_scale buffer-resource bound is 2× too small for BM=64 (atomic path).
A_scale is laid out in fixed 32-row chunks, and the read side addresses it accordingly at line 333:
const int chunk_base = (BM == 16) ? (m_row / BM_GRID) : (m_row / 32);
a_scale_s_base[sub] = (chunk_base + sub) * kAS_per_chunk_dw * 4;
So the chunk-stride divisor is 16 for BM=16 and 32 for everything else — it is not BM. But here the bound divides by kAtomic ? BM_GRID :
32, which only matches the addressing for BM∈{16,32}:
For BM=64 the rsrc extent is (MAX_M/64)*kAS_per_chunk_dw, but addressing reaches (MAX_M/32)*kAS_per_chunk_dw. Once m_row ≥ MAX_M/2, the
hardware buffer-bound check fails the scale loads and returns 0 → e8m0 = 0 → scale 2^-127 → those output rows silently collapse to ~0.
Wrong results, no error.
There was a problem hiding this comment.
Good catch. A_scale is addressed in 32-row chunks ((BM==16)?16:32, not BM), but the bound divided by kAtomic ? BM_GRID : 32, so for BM=64 it covers only max_sorted/64 chunks while addressing reaches max_sorted/32. Rows past max_sorted/2 then read out of bounds, the e8m0 comes back 0, and those outputs collapse to ~0.
Fixed by matching the bound to the addressing: (BM==16) ? BM_GRID : 32. It was masked by the old large MAX_M and only surfaced once the bound was tightened to max_sorted. It also went unnoticed because the BM=64 gemm2 kernel is never dispatched (the tuned CSV only uses BM in {16,32,128}, and there is no BM=64 gemm1/sort to feed it).
Fix: bd5ba79, test: 09e0e1d (BM=32 vs BM=64 on identical inputs; fails before, passes after).
| (257, 7168, 512, 9), # DSR | ||
| ] | ||
|
|
||
| MAX_M = 655360 |
There was a problem hiding this comment.
this is the hard limit of topk * tokens? Then all prefill inputs >72.8K will fail and produce wrong outputs?
There was a problem hiding this comment.
You're right that MAX_M is a hard cap on the sorted token count, and inputs past it would produce wrong outputs. It's not really a bug in practice, since MAX_M is set far above any realistic single-call input, so nothing hits it. This change just removes the fixed cap and uses the actual sorted length (M * topk, i.e. sorted_m) for the buffer-resource bounds check, so it scales with the real input instead of relying on a large constant.
See 82e8e0a for the fix and ee60910 for the 128k validation test.
…emm2 kernels Eliminate the MAX_M template parameter from the gemm1 and gemm2 kernels, replacing it with a runtime-calculated max_sorted value. This change enhances flexibility and correctness in buffer resource calculations, ensuring proper handling of varying input sizes. Update related launch functions and code generation scripts accordingly.
…ffer bound Validate the runtime max_sorted fix at 128k tokens via the per-token MoE invariant moe(128k) == concat(16 x moe(8k)): the 8k chunks stay within any bound (trusted reference) while the 128k run exercises the large sorted-row count (>655360) that the old compile-time MAX_M clipped. Uses random weights (self-consistency needs only identical weights + routing across runs) and masks the kernel's inherent FP-reduce nondeterminism by counting a break only where the kernel is self-deterministic yet full != chunked.
The A_scale buffer-resource bound divided by `kAtomic ? BM_GRID : 32`, but the read side addresses A_scale in fixed 32-row chunks (chunk_base = (BM==16) ? m_row/BM_GRID : m_row/32), i.e. the divisor is 16 only for BM=16 and 32 otherwise, never BM. For BM=64 this under-sized the descriptor 2x: addressing reaches max_sorted/32 chunks while the bound only covers max_sorted/64, so every sorted row with m_row >= max_sorted/2 fails the hardware bounds check, the scale load returns 0 (e8m0=0 -> 2^-127), and those output rows silently collapse toward zero. Match the bound divisor to the addressing: (BM==16) ? BM_GRID : 32. This was masked under the old fixed MAX_M (large enough to cover everything) and only surfaced once the bound was tightened to the runtime max_sorted.
Drives mxfp4_moe_gemm2_a4w4 directly and compares the BM=32 atomic kernel (correct bound) against BM=64 (the kernel whose A_scale bound was 2x too small) on identical inputs, which must agree since both address A_scale in 32-row chunks. M_logical/cumsum are chosen so the BM=64 sorted length exceeds the buggy max_sorted/2, placing the upper rows in the clipped region. fp4 partials are forced non-negative so the per-token atomic sum has no cancellation (noise floor ~0), making the clipped-row signal unambiguous. Fails on the buggy bound (7688/8192 rows), passes after the fix (0/8192).
…4_INTERMEDIATE The fp4 gemm2-output path quantizes each expert's output to 4-bit before the topk reduce. That before-sum quant is lossy: fine for gsm8k but it degrades other evals. Make it opt-in via AITER_MXFP4_INTERMEDIATE=1 (off by default), so real evals run the accurate bf16 reduce unless explicitly enabled.
Replace the eight hand-written template-dispatch switches in mxfp4_moe_aux.cu
with codegen'd extern "C" instances + string-keyed lookup tables, mirroring the
gemm1/gemm2 dispatch. gen_instances.py gains a --target {gemm,aux} flag so a
single shared SHAPES list drives both: adding a shape regenerates gemm and aux
together. New aux/codegen/mxfp4_moe_aux_dispatch.h holds the per-entry function
pointer types plus launch-config constants; module_moe_mxfp4_aux now runs the
codegen via blob_gen_cmd (was none).
Covers all eight aux entries: sort_quant, sort (threestage / inline_quant /
inline_quant+zero_init), quant, sort_scales, scatter_reduce, scatter_reduce_q.
Generated instances are byte-identical template instantiations of the prior
launches; verified numerically identical to the hand-switch build (old and new
show the same FP nondeterminism on the same cancellation rows) and all
MB16/MB32/MB128 keys resolve e2e on the Kimi NE=385 shape.
The single full-vs-full noise sample sporadically missed catastrophic- cancellation rows (whose full-vs-chunk difference comes from the differing reduction order, not a bug), causing ~1-in-4 false failures. Estimate the noise floor as a union over several full re-runs (--noise-reps, default 4) and, since full/chunked use different token counts and a same-order probe can never fully capture cross-order cancellation, gate on the COUNT of residual breaks: the max_sorted bug corrupts ~half the rows (~NT/2) while cancellation noise is a handful, so tolerate <0.1% of rows and fail only on a bug-scale fraction. Reliable PASS across repeated runs; still catches the bug with a ~500x margin.
|
Hi @coderfeli, Thanks for all the feedback! We went through all your inline comments about the hardcoded shapes and constraints, and have made several commits to address them. Initially, we only tuned and tested the Kimi-K2.5 TP4 shape in detail. The real constraint, however, is just We have now wired the aux kernels into the same CSV codegen flow as Could you please take another look when you get a chance? Please let us know if there is anything else we should address. |
|
Hi @coderfeli, Based on the latest commits, we added a new end-to-end evaluation for the native HIP MXFP4 a4w4 MoE backend on Kimi-K2.5. The test compares three configs:
Technical detailThe backend uses different pipelines for different token ranges:
Test PlanEnvironment: # 4x gfx950 / MI355X, TP4
# torch / ROCm: 2.10.0+rocm7.2.3
export ATOM_USE_TRITON_MXFP4_BMM=1
export AITER_QUICK_REDUCE_QUANTIZATION=INT4
export ATOM_ENABLE_HIP_MLA_DECODE=1
python -m atom.entrypoints.openai_server --model Kimi-K2.5-MXFP4 \
-tp 4 --kv_cache_dtype fp8 --max-model-len 10240 \
--max-num-batched-tokens 16384 --gpu-memory-utilization 0.7 --no-enable_prefix_cachingAccuracygsm8k, full 1319 examples, fewshot=5, eval concurrency=64.
The fp4 intermediate path only changes gsm8k by about PerformanceMedian, Kimi-K2.5 TP4, ISL 8192 / OSL 1024.
Detailed PerformanceInteractivity, tok/s/user
TPOT, ms
Throughput/GPU, tok/s
E2E latency, s
|
…rt shapes Cherry-picked PR #3470's last-2-days commits (aux instance codegen, MAX_M removal, gemm2 BM=64 A_scale fix, AITER_MXFP4_INTERMEDIATE gate, tests). Two adaptations for this branch: - The PR codegen references the aux kernels under aux/; our branch renamed that to moe_aux/ (commit fee65e2, NFS compatibility). Repoint the codegen kernel includes, the dispatch-header include path, and the header location to moe_aux/. - Add AUX_EXTRA_SHAPES (MiniMax NE256/H3072, dsv3 NE32, Kimi-K2 NE384, Qwen3.5 NE512/TOPK10/H4096) to the AUX (sort) codegen only -- not the HIP gemm codegen -- so the BM16 inline_quant sort instances exist for the FlyDSL-port shapes without emitting HIP gemm instances that needn't compile. bench_up_moe_v2 --shapes all: all 9 shapes 1.08-1.68x vs fly, cos 0.97-0.99, correct across M (8/32/128). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: fsx950223 <fsx950223@outlook.com>
|
@ColorsWind conflicts |
… large M) The synthesized (forced / no-CSV) mxfp4 port pipeline pinned BM16 inline_quant at all M -- decode-tuned but a big loss at prefill (M>=2048 ~main+40%, since BM16 atomic is latency-bound). Pick BM by M instead: BM16 inline_quant+atomic for M<=1024, BM128 nonatomic for M>1024 (throughput-bound prefill). Only the Kimi/DSR SHAPES have the non-inline BM128 path codegen'd (threestage sort + quant + sort_scales), so the tier switch is gated to them; other shapes stay BM16. KIMI (test_moe_2stage, PR vs main): M=4096 750us vs 793 (PR +6%), M=8192 1361 vs 1374 (PR +1%) -- recovering the large-M win that forced-BM16 lost (was main +40% at 2048, ~tie-but-slow at 8192). Matches PR #3470 compare.md's decode-win / mid-valley / large-M-win profile. Thresholds are heuristic; production tunes via CSV. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: fsx950223 <fsx950223@outlook.com>
|
Could the kernel support general weight and scale shuffle? And the performance of kernels which INTER=256 are not as good as INTER=512. |
|
Hi @fsx950223 Thanks for the review!
AITER's Our kernel performs the
Would you mind sharing your performance results for Our current kernel has been extensively tuned and validated on Kimi K2.5, but we would also be happy to investigate and improve performance for other shapes if there are gaps. |
This is current branch vs main branch moe performance compare |
…figs Tune the flydsl a4w4 port on the merged branch and deploy the results. tune_mxfp4_moe.py: - Tag emitted rows `_tag=mxfp4_moe` (was mxfp4_guinterleave) so they route to the flydsl port on this branch (mxfp4_guinterleave now selects randomflow's #3470). - Gate variants against a torch bf16 MoE reference (new torch_moe_ref) for ALL shapes instead of run_fly -- the legacy CK 2stages reference is a slow per-shape build and segfaults on dsv4. Correct a4w4 variants land ~0.95 cos vs this ref; GATE lowered 0.90 -> 0.85 for margin. aiter/configs/tuned_fmoe.csv: - Add 56 tuned mxfp4_moe rows (14 shapes x M=8/128/2048/8192). The auto-merge had dropped the port's tuned rows (0 mxfp4_moe before); this restores them. No clash with the #3470 mxfp4_guinterleave rows (disjoint shape keys). Picks: BM16 atomic (decode) -> BM32/BM64 cshuffle (mid) -> BM128 nonatomic (prefill). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: fsx950223 <fsx950223@outlook.com>
…e + dedup The 107 mxfp4_guinterleave rows the merge added to tuned_fmoe.csv (base had 0) are the dev-branch flydsl PORT's tuned rows -- the dev port used the "mxfp4_guinterleave" shuffle_kind. On this branch the flydsl port routes via "mxfp4_moe" (mxfp4_guinterleave now selects randomflow's HIP #3470), so retag them mxfp4_moe. Then dedup the mxfp4_moe rows by shape key keeping best-us, which folds the freshly-tuned 56 rows from the prior commit into the 107 (8 token tiers). Result: 107 mxfp4_moe rows, 0 duplicates, 0 guinterleave in tuned_fmoe.csv. randomflow's base rows (kimik2_5 model_config) are untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: fsx950223 <fsx950223@outlook.com>
The flydsl a4w4 MoE port was named inconsistently: the dispatch and the
tuned CSV `_tag` use "mxfp4_moe", but the dev branch's old port alias
"mxfp4_guinterleave" survived in _MXFP4_PORT_KIND, the header/_port_kind
comments, and 4 test sites. Because
_port_kind = w1.shuffle_kind == _MXFP4_PORT_KIND # "mxfp4_guinterleave"
a model setting only shuffle_kind="mxfp4_moe" (the natural, CSV-consistent
way) got _port_kind=False -> the flydsl gemm backend was not auto-selected
-> silent fallback to HIP gemm. It was masked by callers also setting
gemm{1,2}_backend="flydsl" explicitly.
Unify on "mxfp4_moe" as the single port kind:
- _MXFP4_PORT_KIND="mxfp4_moe", _MXFP4_KINDS=("mxfp4_moe",)
- fix the (inverted) header comment + the _port_kind comment
- tests: shuffle_kind="mxfp4_moe"; drop the now-redundant explicit
gemm{1,2}_backend from test_moe_2stage so it verifies shuffle_kind-alone
auto-select AND hits the tuned mxfp4_moe CSV rows (BM64/cshuffle) instead
of the M-adaptive synthesis fallback.
"mxfp4_guinterleave" now refers exclusively to randomflow's HIP a4w4 (#3470).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: fsx950223 <fsx950223@outlook.com>
* [PERF] MXFP4 (a4w4) MoE backend for gfx950 (#3470) --------- Co-authored-by: ColorsWind <14761584+ColorsWind@users.noreply.github.com> Co-authored-by: Zesen Liu <ftyghome@gmail.com> Co-authored-by: GnSight <ftyg@live.com> Co-authored-by: Wulley <bernard.liu@amd.com> Co-authored-by: who who who <fsx950223@outlook.com> Co-authored-by: zhutaoyu <zhutaoyu97@gmail.com> Co-authored-by: Claude Opus 4 (1M context) <noreply@anthropic.com> Co-authored-by: zhangnju <zhangnju@126.com> Co-authored-by: Bernard-Liu <bernaliu@amd.com> Co-authored-by: Cursor <cursoragent@cursor.com>

[PERF] mxfp4_moe — a native MXFP4 (a4w4) MoE backend for gfx950
Motivation
Kimi-K2.5 / DeepSeek-R1 serve MoE FFNs with MXFP4 (a4w4) weights. The existing FlyDSL (MLIR) a4w4 MoE path leaves performance on the table across the whole token range — it is launch/latency-bound at decode-size M and not fully HBM-bound at prefill-size M. This PR adds
mxfp4_moe, a native pure-HIP MXFP4 MoE backend that is faster than FlyDSL at every M bucket on MI350X.Technical Details
The backend is two JIT modules plus a
fused_moeintegration:module_moe_mxfp4_aux— token sort, mxfp4 quant (bf16 → fp4 + e8m0, group=32), scale sort/shuffle, and the weighted scatter-reduce.module_moe_mxfp4_gemm— the two a4w4 GEMMs (gemm1 fused-SwiGLU, gemm2 down-proj) with codegen'd kernel-name dispatch (gen_instances.py) and MFMA f4f4 microkernels.fused_moewiring —mxfp4_moe_*kernel names route to an_mxfp4_moe_runpipeline via aMOEMetadatahook; per-M kernel selection comes from ashuffle_kind-tagged tuned CSV, so the backend ships its own tuned rows next to the default tuning without dedup conflicts.Several pipelines, dispatched per token-count M
The optimal fusion/accumulation strategy flips between decode-size and prefill-size M, so we designed several pipelines and pick one per M from the tuned CSV:
sort → quant-fused-gemm1 → gemm2 (atomic-add reduce). Quant is folded into gemm1 and gemm2 atomic-adds straight into the output, removing the per-sorted-row partial buffer and the separate quant / scatter passes. (The gemm2 B-load additionally has a per-M-tuned cached vs non-temporal choice.) Wins when M ≲ BM and the path is launch/latency-bound.sort → quant → sort-scale → gemm1 → gemm2 [→ scatter-reduce]. Prologue runs as separate full-throughput kernels. At mid M (BM=32) gemm2 atomic-accumulates straight into the output (no separate reduce); at large M (BM=128) gemm2 writes per-sorted-rowflat_outand a dedicated non-atomic scatter-reduce does the weighted sum — avoiding the atomic contention that grows with M. HBM-bound regime.The core tradeoff: fusion + atomics win when operator shapes are complementary and launch overhead dominates (small M); at large M they break down — the same token is read by multiple experts (duplicated quant work) and atomic contention grows — so the separate, non-atomic path wins.
On top of the large-M non-atomic path, an optional mxfp4-intermediate variant (CSV-gated, enabled on the largest prefill buckets) stages gemm2's
flat_outas packed fp4 + e8m0 (mxfp4-out epilog) and reads it back withscatter_reduce_q, cutting the reduce's HBM traffic ~3.8×.Test Plan
Machine / shape
ca781d81.Cache / timing
perf_counter+synchronize), median of 30 iters, warm cache (weights resident in L2; no per-iter flush).mxfp4_moeis driven through the productionfused_moeentry — per-M kernel dispatch comes straight from the tuned CSV (including the mxfp4-intermediate path on the largest buckets), so the numbers carry real host-side dispatch overhead; FlyDSL is timed at its kernel stages (best block_m reported). Same routing and fp4 weights for both — if anything this is conservative formxfp4_moeat small M.Correctness
End-to-end
cos(kernel, bf16_truth)is 0.975–0.978 across the full M range and never worse than the fp4 reference — all pass.Performance (end-to-end, mxfp4_moe vs FlyDSL)
mxfp4_moeis faster than FlyDSL across the entire token range (1.12×–1.80×). At the largest buckets (M ≥ 8192) the CSV switches to the mxfp4-intermediate path; its cos vs FlyDSL drops to ~0.981 (the inherent loss of the before-sum fp4 quant) while still pulling ahead in latency.